昨天介紹了 SharedArrayBuffer,使用 SharedArrayBuffer 可以在不同的線程中共享記憶體,達到高效的運算功能,但隨之而來的缺點就是不同線程間操作同一記憶體帶來的 競爭衝突 (race condition)
假設有兩個線程各自都會將同一個變數 +1,在理想的狀態下,他們會這樣執行,最後的變數值為 2
但如果他們在執行的時間上交錯時,可能會發生以下狀況,最後導致變數的值為 1
Atomics 用來在 Javascript 中解決 race condition 的問題,使用 Atomics 代表每一次操作都是單一原子化,無法分割的,什麼是無法分割的意思呢?以上述例子來看,指的就是 線程1 執行時的讀取、增加、寫回,三個步驟是會綁在一起執行的,而不會出現以上線程1 與 線程2 執行交錯的狀況
Atomics 底下提供了數個靜態方法,這裡我們只先簡短介紹以下範例會用到的 Atomics.add() 及 Atomics.load(),使用這兩個方法分別可以以原子操作的方式更改值及讀取值:
const buffer = new SharedArrayBuffer(16);
const uint8 = new Uint8Array(buffer);
uint8[0] = 7;
// 將 uint8[0] 以原子操作的方式 +2
console.log(Atomics.add(uint8, 0, 2));
// 將 uint8[0] 以原子操作的方式讀取
console.log(Atomics.load(uint8, 0)); // 9
另外這些原子操作的方法,第一個參數傳入的都是 TypeArray 型別,像上面使用的是 Uint8Array
目的
SharedArrayBuffer 時可能發生的 race condition 狀況Atomics 原子操作避免不同線程間造成的 race condition
說明
SharedArrayBuffer 創建的同一塊記憶體
建立 SharedArrayBuffer 並傳送訊息
首先建立 SharedArrayBuffer,並將 max: 每個線程計算的次數(預設 1000 次) 及 isAtomicEnabled: 是否啟用 Atomic 也一起傳到 worker 線程
// 主線程
const worker = new Worker('public/worker.js');
document.querySelector('button').onclick = (e) => {
  const sab = new SharedArrayBuffer(4);
  const arr = new Uint32Array(sab);
  const max = document.querySelector('.max').value;
  const isAtomicEnabled = document.querySelector('.enable').checked;
  
  worker.postMessage({ arr, max, isAtomicEnabled });
}
主線程改變記憶體中的數值
主線程在送出訊息給 worker 後,馬上改變 arr[0] 這塊記憶體中的數值,這裡利用了 requestAnimationFrame,要求瀏覽器在每幀中不斷對 arr[0] 這塊記憶體一直執行累加操作,直到達到了 max 次後才結束,並把累加後的結果顯示在畫面上
這裡每輪的累加操作會根據是否啟用 Atomics 而執行不同的方法,有開啟的話就使用 Atomics.add,沒有的話就使用一般矩陣的加法 arr[0] += 1
// 主線程
let count = 0;
const add = () => {
  if (count >= max) {
    // 主線程累加計算結束
    // 顯示 arr[0] 這塊記憶體的數值
    console.log('main result:', arr[0])
    document.querySelector('.result').textContent = arr[0];
    return;
  }
  count++;
  
  if (isAtomicEnabled) {
    Atomics.add(arr, 0, 1);
  } else {
    arr[0] += 1;
  }
  const value = isAtomicEnabled ? Atomics.load(arr, 0) : arr[0];
  console.log('main:', count, value);
  requestAnimationFrame(add);
}
requestAnimationFrame(add);
worker 線程改變記憶體中的數值worker 線程在收到訊息後,也跟主線程執行同樣的邏輯,對同一塊記憶體 arr[0] 執行累加的操作,累加完畢後將 arr[0] 這塊記憶體的數值傳遞回主線程,並由主線程顯示在畫面上
// worker 線程
self.onmessage = (e) => {
  const { arr, max, isAtomicEnabled } = e.data;
  let count = 0;
  const add = () => {
    if (count >= max) {
      // worker 線程累加計算結束
      // 將 arr[0] 這塊記憶體的數值傳遞給主線程知道
      console.log('worker result:', arr[0]);
      self.postMessage(arr[0]);
      return;
    }
    count++;
    if (isAtomicEnabled) {
      Atomics.add(arr, 0, 1);
    } else {
      arr[0] += 1;
    }
    const value = isAtomicEnabled ? Atomics.load(arr, 0) : arr[0];
    console.log('worker:', count, value);
    requestAnimationFrame(add);
  };
  requestAnimationFrame(add);
};
主線程接收 worker 線程傳來的資料
當 worker 線程計算完畢後,會將最新的 arr[0] 這塊記憶體的數值丟回給主線程,並由主線程顯示這塊記憶體的值
worker.onmessage = (e) => {
    const maxCount = e.data;
    document.querySelector('.result').textContent = maxCount;
}
arr[0] +=1 時,會發現有時候結果的值不等於 2000,這代表出現了 race condition 的狀況
另外大家也可以打開 devtool 中的 console 查看每輪計算的數值,會發現當出現 race condition 時,同一輪中 主線程(main) 跟 worker線程 會讀取到 同樣的數值(467),這就像是這篇開頭提到的狀況,兩個線程執行的時間交錯,所以一開始同時讀取到的都是 0,導致最後相加結果為 1 而不是 2,這裡也是類似的狀況,其中某輪的運算兩個線程同時讀取到的值都是 467,所以寫回的時候只更新成 468 而不是 469,因此最後累加出來的值就會比 2000 還少了


使用 Atomics 可以避免不同線程操作同一塊記憶體造成的 race condition 問題,但實際上使用多線程開發還需要考慮許多同步的問題,而這些同步的問題實際上是很困難且繁瑣的,因此正確使用 SharedArrayBuffer 的方式應該是依賴專業開發人員提供的套件來處理多線程操作,但或許是網頁上極少有用到多線程的狀況,看來還沒有專業的開發人員編寫相關套件專門處理這種同步的問題。
Avoiding race conditions in SharedArrayBuffers with Atomics
漫畫方式學 Atomic 的親切文章
Using JavaScript SharedArrayBuffers and Atomics
並行程式的潛在問題(一)